【乗るしかない】AWS LambdaとLINE BOT APIでRSSを通知する【このビッグウェーry】
こんにちは、せーのです。今日は何かと話題のチャットBOTを使ってこのDevelopers.ioのお知らせBOTを作ってみたいと思います。新しもの好きなので。
チャットBOTの時代はくるのか?
さてここのところLINEより「LINE BOT API」が発表され、更にFacebookからもチャットツールである「Messenger」に対してプログラムで制御するためのプラットフォーム「Bots for Messenger」が発表されました。これにより巷では「チャットBOTの時代が来る」「ゴールドラッシュならぬ"ボットラッシュ"だ」なんて言われております。
ChatOps
開発者の間では数年前から開発に普段エンジニア同士の情報伝達のために使うチャットツール(Slack, Chatwork等)に一定のコマンドを打ち込むことによってコンパイル、ビルド、コミットやPush、CI等を行う「ChatOps」という方式を使う事が多くなりました。例えばSlackのBOT向けに「Deploy XXX」と打ち込むとデプロイ作業がスタートして、終わるとBOTから「Deploy XXX Done.」と返事が返ってくる、というようなものです。 ここから派生してBOTに色々な日常業務をしてもらうことはちょっとした流れになっていました。
弊社ではChatworkを使っているのですが、毎時「くらめそちゃん」というBOTが日本、ドイツ、シアトルの現在時刻を教えてくれることで海外オフィスとのコミュニケーションを円滑にしたり、お客様からの問い合わせに使用しているチケット管理のステータスを教えてくれたりします。 最近では札幌オフィスのウォーターサーバーに入れる水を発注するとくらめそちゃんがその旨をChatworkにつぶやいてくれる、という仕組みを作ったりしました。
チャットBOTの強みは「知名度」と「自然言語処理」「テキスト以外の表現」
そんな感じで意外と開発者には馴染みの深いチャットBOTですが、それらと今回発表されたBots for MessengerやLINE BOT APIには何か違いがあるのでしょうか?
ドキュメントを読んだ感じだとLINE BOT APIに関しては今までのチャットBOTとそう代わりはないように見えます。違うのはやはり「LINE」という一般の人が普通に使用しているプラットフォームでチャットBOTができることで、一般向けサービスが開発しやすい、というビジネス的観点からみたメリットが挙げられます。開発者じゃないとなかなか携帯にSlackは入っていないので。。。
一方Bots for Messengerの方はもう少し面白い使い方が出来そうです。それは自然言語処理、という点においてこのプラットフォームは優位に立ちそうだからです。Bots for Messengerには「Wit.ai's Bot Engine」という人工知能エンジンにより自然言語の意図を理解し、学習することができると言われています。ですので特定のコマンドとなる文字列でのアクションではなく「今週末公開の映画を教えて」「今週末公開の映画は4件あります」「それって札幌でもやってる?」「札幌ではシネマフロンティアで2件、ユナイテッド・シネマ札幌で3件、スガイディノスで1件公開されます」のような会話をするようなやり取りで聞きたい情報を得られる事ができるようになります。こうなるとちょっと夢が広がりますね。
LINE BOT APIを使う時のポイントはやはり「スタンプ」ということになるでしょう。LINEの特徴でもあるスタンプをどのように織り込むかによって面白い使い方が出来そうです(BOT側からは無料スタンプは使えるようです)。Bots for Messengerはボタンをつけることができるので、そのボタンUIを上手に使うことがカギになると思います。例えば最近の気になる服や靴をMessengerで聞くと、お薦めとその画像、そして購入ボタンが一緒に返ってくる、というイメージです。
やってみた
仕様を決める
まずはこのBOTが何をするのか、を決めます。今回は
- RSSを読んできて過去15分以内に投稿されている記事があればBOTから流す
という機能を実装したいと思います。
LINE BOT APIを使うにはLINEにてビジネスアカウントを作り、そこからChannel ID / Channel Secret(所謂API key / API Secret)を使ってAPIを叩く、というような感じになります。先着1万名、という事だったのでとりあえずアカウントを取ってみました。
いきなりの挫折
さて、じゃあ作ろうか、とドキュメントを読んでいると中にこんな文章が。
The BOT APIs can only be called from registered servers. To register servers, you need to add IP addresses or a network to your Channel. (BOT APIは登録されたサーバーからしか呼び出すことができません。サーバーを登録するにはIPアドレスかネットワークをチャンネルに追加してください。) If an BOT API is called from a not registered servers an HTTP status code of 427 will be returned. (登録されていないサーバーからBOT APIを呼び出すとステータスコード: 427を返します。)
つまりBOT APIを呼び出すには固定IPを登録しないと使えないんですね。当然ですけど関数叩いたら消えてしまうLambdaはIPなんて固定できません。でもEC2は使いたくない!! ということでLambdaをVPC内に入れてしまって、NAT Gatewayを立てて通信はNATを通して立てることにしました。
次に引っかかったのはこちら。
To receive requests sent from the LINE platform, you must register a callback URL for your BOT API server on the Channel Console. (LINEからリクエストを受けるにはコールバックURLをチャンネルコンソールから登録しなくてはいけません) Once registered, requests will be sent to your callback URL. The callback URL must use HTTPS. (コールバックURLを登録することで、以後リクエストはそのURLに送られます。コールバックURLは必ずHTTPSを使って下さい)
HTTPSプロトコルのコールバックURLが必要なんですね。HTTPSでLambdaに繋げられる入口、、、と言えば浮かぶのはAWS IoTとAPI Gatewayでしょうか。サクッと作れそうなのでAPI Gatewayを使ってみることにします。
Lambdaだけサクサク書けば終わる、と思ってたのですがさすがにそれは甘かったです。笑 アーキテクチャとしてはこんな感じになります。
ではやっていきましょう。
NAT Gateway, API Gateway, Lambda(ガワだけ)の構築
まずはVPCにサブネットをひとつ作り、そこにNAT GatewayをEIP付きで構築します。Nat Gatewayについてはこちらを参考にして下さい。
次にAPI Gatewayです。Resourceをひとつ追加し、そこにPOSTメソッドを追加します。認証はOpenでいきます。API Gatewayについてはこちらを参考にして下さい。
合わせてLambda functionを作成します。中身は後で実装するとして、とりあえず今は入ってきた値をコンソールに返す、という処理だけ入れておきます。
LINE Developer Centerの設定
次にLINE側の設定を行います。先ほどAPI Gatewayを構築した時に出来たアクセス用のエンドポイントを確認します。
これをLINEのDeveloper用画面からChannelを選択し、その下の方にある「callback url」に設定します。なぜかポート番号(443)をドメインの後ろにつけなくてはいけないそうなので気をつけましょう。
次にLINE BOT APIがたたけるIPのホワイトリストを設定します。先程構築したNAT Gatewayの EIPを確認して
それをLINE側の[Server IP Whitelist]から入力します。
テスト用にBOTを登録してみましょう。Channelの下に登録用のQRコードがあるので携帯から読み込みます。
BOTを追加したらとりあえず何か打ってみましょう。
設定がうまく行っていればCloudWatchから先程打ったリクエストが見えるはずです。
中身はそれぞれこうなっています。
名前 | 型 | 説明 |
---|---|---|
id | String | メッセージID |
contentType | Integer | メッセージの種類。1-テキスト、2-画像、3-動画、4-ボイス、7-位置情報、8-スタンプ、10-コンタクト |
from | String | メッセージの送り元ID。nullの場合はグループからの送信。 |
createdTime | Integer | 送信日時(UNIXTIME) |
to | Array of strings | メッセージの受け取り先ID |
toType | Integer | 受け取ったユーザーの種類。1-個人、2-ルーム、3-グループ |
contentMetadata | Object | メッセージの詳細メタデータ |
text | String | テキストメッセージ |
location | Object | 位置情報 |
Signatureの認証を入れる
現在の状態ですとAPIのURLがわかると色々な所から叩けてしまうため、SignatureによるValidationを書きます。 LINEのSignatureは[X-LINE-ChannelSignature]というヘッダに入っていて、LINEからきたbody、つまり全てのJSON文字列をHMAC-SHA256形式のアルゴリズムでCHANNEL SECRETをkeyにして暗号化したダイジェスト値と同じ文字列が入っています。[X-LINE-ChannelSignature]ヘッダをLambdaまで転送してきてヘッダの中とJSON文字列をCHANNEL SECRETでダイジェストした文字列を比べてみて合っていれば、そのリクエストは間違いなくLINEから届いたもの、ということになります。
まずは[X-LINE-ChannelSignature]ヘッダをLambdaまで持ってくるようにAPI GWのmapping templateを変更します。mapping templateは[Integration Request]というところにあります。
{ "CHANNELSIGNATURE" : "$input.params('X-LINE-CHANNELSIGNATURE')", "body" : $input.json('$') }
ヘッダは$input.params([ヘッダ名])という関数で取れます。元々のリクエストデータは$input.json('$')で取れますので、これらをJSONのフォーマットでLambdaに送ります。
次に受け取るLambda側です。今回は暗号化にcryptoというモジュールを使います。単純化するとこんな感じになります。
console.log('Loading function'); const CHANNEL_SECRET = 'XXXXXXXXXXXXXXXXXXXXXXX'; var crypto = require('crypto'); exports.handler = (event, context, callback) => { var signature = event.CHANNELSIGNATURE; var body = new Buffer(JSON.stringify(event.body), 'utf8'); var hash = crypto.createHmac('sha256', CHANNEL_SECRET).update(body).digest('base64'); if (hash != signature){ context.fail("Signature validation failed."); }
ポイントは持ってきたリクエストデータ(event.body)をUTF-8で一旦Bufferオブジェクトに変換することです。CryptoモジュールはデフォルトでasciiのBufferオブジェクトに変換されます。asciiのBufferオブジェクトは7bitデータを専用に扱います。そのため、ascii以外の文字列が入ってきた場合は下位7bitのみ抽出して扱われます。そのため全体のデータが変わってしまい、正しく変換されません。事前にUTF-8にてバイナリ化してしまうことでそれを防ぎます(ここで数時間ハマりました。。。)。しかも今回はRSSをとってきて投げる、というBOTを作るので特にメッセージを受けて処理するわけではありません。ですのでこのコード、使わないということに今気が付きました。
まあ、いいでしょう。それでは実装に入りましょう。
RSSフィードを取得してLINEに投げる
とりあえずLINEに投げるテスト
まずはLINEにメッセージが投げられるかテストしてみましょう。やり方は至ってシンプルで
ヘッダ
- X-Line-ChannelID: CHANNEL ID
- X-Line-ChannelSecret: CHANNEL SECRET
- X-Line-Trusted-User-With-ACL: CHANNEL MID
を設定し
- TARGET URL: https://trialbot-api.line.me/v1/events
に向けてPOSTで
名前 | 説明 |
---|---|
to | メッセージの受け取り先MID(リスト形式) |
tochannel | 1383378250 (固定) |
eventType | "138311608800106203" (固定) |
content | メッセージの中身。上の形式に沿えばOK。 |
を投げてやればOKです。
console.log('Loading function'); const CHANNEL_SECRET = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; var https = require('https'); var options = { hostname: "trialbot-api.line.me", port: 443, path: "/v1/events", method: 'POST', headers: { 'Content-Type':'application/json; charser=UTF-8', 'X-Line-ChannelID':'99999999999', 'X-Line-ChannelSecret':CHANNEL_SECRET, 'X-Line-Trusted-User-With-ACL':'XXXXXXXXXXXXXXXXXXXXXXXXXXXXX' } }; exports.handler = (event, context, callback) => { const req = https.request(options, (res) => { var body = ''; res.setEncoding('utf8'); res.on('data', (chunk) => body += chunk); res.on('end', () => { context.succeed('Successfully processed HTTPS response'); }); }); var content={ 'contentType': 1, 'toType': 1, 'text': 'くらめそちゃんだよ!' }; var senddata={ 'to': ["XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"], 'toChannel':1383378250, 'eventType':"138311608800106203", 'content':content }; req.on('error', callback); req.write(JSON.stringify(senddata)); req.end(); };
RSSをとって来て投げる
次にRSSを取ってきて投げてみましょう。今回は取得するのにrequest、パースにfeedparserを使います。ついでなのでLINEに投げるところもrequestで書き換えてみましょう。
var FeedParser = require('feedparser'); var request = require('request'); var feed = 'https://dev.classmethod.jp/feed/'; var options = { url: "https://trialbot-api.line.me/v1/events", method: 'POST', headers: { 'Content-Type':'application/json', 'X-Line-ChannelID':'XXXXXXXXXXX', 'X-Line-ChannelSecret':'XXXXXXXXXXXXXXXXXXXXXXXXXXXXX', 'X-Line-Trusted-User-With-ACL':'XXXXXXXXXXXXXXXXXXXXX' }, json: true, body: '' }; var senddata={ 'to': ["XXXXXXXXXXXXXXXXXXXXXXXXXXXXX"], 'toChannel':1383378250, 'eventType':"138311608800106203", 'content':{ 'contentType': 1, 'toType': 1, 'text': 'くらめそちゃんだよ!' } }; var req = request(feed); var feedparser = new FeedParser({}); var items = []; var pubdate = ""; req.on('response', function (res) { this.pipe(feedparser); }); feedparser.on('meta', function(meta) { console.log('==== %s ====', meta.title); }); feedparser.on('readable', function() { while(item = this.read()) { //console.log("item.pubdate: " + item.pubdate + ' ' + item.title); pubdate = item.pubdate.getTime(); now = new Date().getTime(); if (now - pubdate < 43200000){ items.push(item); } } }); feedparser.on('end', function() { // show titles items.forEach(function(item) { senddata.content.text = '- ' + item.author + 'が書いた、[' + item.title + ']' + '(' + item.link + ')がアップされましたよー☆'; options.body = senddata; console.log('options: ' + JSON.stringify(options)); request.post(options, function(error, response, body){ if (!error) { console.log(JSON.stringify(response)); console.log(JSON.stringify(body)); console.log('send to LINE.'); if( item == items.length - 1 ){ context.succeed('sending function done.'); } } else { console.log('error: ' + JSON.stringify(error)); } }); }); });
developers.ioのfeedのpubdate(投稿日時)と現在時刻のUNIXTIMEの差でLINEに投稿をなげます。テストとして12時間前(43200000ms)までに投稿された著者、タイトル、リンクをLINEになげてみます。
テスト成功ですね。あとは投稿を投げる時間を15分(900000ms)にして、LambdaのSchedule Eventを15分に設定すればOKです。
まとめ
いかがでしたでしょうか。API自体はとてもシンプルで簡単です。ただLINEはSlackやChatworkと違い「部屋になげる」というより個人同士のつながりとなるので仕様としては
- ユーザーがBOTと友だちになったリクエスト(opTypeという項目で判別します)でMIDを保管
- メッセージを投げる時にtoに保管したMIDを全て入れる
- ユーザーがBOTをブロックされたリクエストを受けたら対象となるMIDを削除
という感じで作りこんでいけばいいかと思います。 ←このしくみを別の記事に書きました。
ただこのソリューションを実装するのにNAT Gatewayはオーバースペックですね。やるのであればt2.nanoやt2.micro当たりのEC2インスタンスをNATとして立てたほうがいいかと思います。
次はFacebook BOTやってみようかなあ。
追記
この記事のアップも無事にくらめそちゃんが伝えてくれました!